package com.reactnativekeyboardcontroller.interactive

import android.os.CancellationSignal
import android.view.View
import android.view.animation.LinearInterpolator
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationControlListenerCompat
import androidx.core.view.WindowInsetsAnimationControllerCompat
import androidx.core.view.WindowInsetsCompat
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import androidx.dynamicanimation.animation.springAnimationOf
import androidx.dynamicanimation.animation.withSpringForceProperties
import kotlin.math.roundToInt

@Suppress("detekt:TooManyFunctions")
internal class KeyboardAnimationController {
  private var insetsAnimationController: WindowInsetsAnimationControllerCompat? = null
  private var pendingRequestCancellationSignal: CancellationSignal? = null
  private var pendingRequestOnReady: ((WindowInsetsAnimationControllerCompat) -> Unit)? = null

  /* To take control of the an WindowInsetsAnimation, we need to pass in a listener to
     controlWindowInsetsAnimation() in startControlRequest(). The listener created here
     keeps track of the current WindowInsetsAnimationController and resets our state. */
  private val animationControlListener by lazy {
    object : WindowInsetsAnimationControlListenerCompat {
      /**
       * Once the request is ready, call our [onRequestReady] function
       */
      override fun onReady(
        controller: WindowInsetsAnimationControllerCompat,
        types: Int,
      ) = onRequestReady(controller)

      /**
       * If the request is finished, we should reset our internal state
       */
      override fun onFinished(controller: WindowInsetsAnimationControllerCompat) {
        reset()
      }

      /**
       * If the request is cancelled, we should reset our internal state
       */
      override fun onCancelled(controller: WindowInsetsAnimationControllerCompat?) {
        reset()
      }
    }
  }

  /**
   * True if the IME was shown at the start of the current animation.
   */
  private var isImeShownAtStart = false

  private var currentSpringAnimation: SpringAnimation? = null

  /**
   * Start a control request to the [view]s [android.view.WindowInsetsController]. This should
   * be called once the view is in a position to take control over the position of the IME.
   *
   * @param view The view which is triggering this request
   * @param onRequestReady optional listener which will be called when the request is ready and
   * the animation can proceed
   */
  fun startControlRequest(
    view: View,
    onRequestReady: ((WindowInsetsAnimationControllerCompat) -> Unit)? = null,
  ) {
    check(!isInsetAnimationInProgress()) {
      "Animation in progress. Can not start a new request to controlWindowInsetsAnimation()"
    }

    // Keep track of the IME insets, and the IME visibility, at the start of the request
    isImeShownAtStart = ViewCompat.getRootWindowInsets(view)
      ?.isVisible(WindowInsetsCompat.Type.ime()) == true

    // Create a cancellation signal, which we pass to controlWindowInsetsAnimation() below
    pendingRequestCancellationSignal = CancellationSignal()
    // Keep reference to the onReady callback
    pendingRequestOnReady = onRequestReady

    // update our state manager
    InteractiveKeyboardProvider.isInteractive = true

    // Finally we make a controlWindowInsetsAnimation() request:
    ViewCompat.getWindowInsetsController(view)?.controlWindowInsetsAnimation(
      // We're only catering for IME animations in this listener
      WindowInsetsCompat.Type.ime(),
      // Animation duration. This is not used by the system, and is only passed to any
      // WindowInsetsAnimation.Callback set on views. We pass in -1 to indicate that we're
      // not starting a finite animation, and that this is completely controlled by
      // the user's touch.
      -1,
      // The time interpolator used in calculating the animation progress. The fraction value
      // we passed into setInsetsAndAlpha() which be passed into this interpolator before
      // being used by the system to inset the IME. LinearInterpolator is a good type
      // to use for scrolling gestures.
      linearInterpolator,
      // A cancellation signal, which allows us to cancel the request to control
      pendingRequestCancellationSignal,
      // The WindowInsetsAnimationControlListener
      animationControlListener,
    )
  }

  /**
   * Start a control request to the [view]s [android.view.WindowInsetsController], similar to
   * [startControlRequest], but immediately fling to a finish using [velocityY] once ready.
   *
   * This function is useful for fire-and-forget operations to animate the IME.
   *
   * @param view The view which is triggering this request
   * @param velocityY the velocity of the touch gesture which caused this call
   */
  fun startAndFling(view: View, velocityY: Float) = startControlRequest(view) {
    animateToFinish(velocityY)
  }

  /**
   * Update the inset position of the IME by the given [dy] value. This value will be coerced
   * into the hidden and shown inset values.
   *
   * This function should only be called if [isInsetAnimationInProgress] returns true.
   *
   * @return the amount of [dy] consumed by the inset animation, in pixels
   */
  fun insetBy(dy: Int): Int {
    val controller = insetsAnimationController
      ?: throw IllegalStateException(
        "Current WindowInsetsAnimationController is null." +
          "This should only be called if isAnimationInProgress() returns true",
      )

    InteractiveKeyboardProvider.isInteractive = true
    // Call updateInsetTo() with the new inset value
    return insetTo(controller.currentInsets.bottom - dy)
  }

  /**
   * Update the inset position of the IME to be the given [inset] value. This value will be
   * coerced into the hidden and shown inset values.
   *
   * This function should only be called if [isInsetAnimationInProgress] returns true.
   *
   * @return the distance moved by the inset animation, in pixels
   */
  fun insetTo(inset: Int): Int {
    val controller = insetsAnimationController
      ?: throw IllegalStateException(
        "Current WindowInsetsAnimationController is null." +
          "This should only be called if isAnimationInProgress() returns true",
      )

    val hiddenBottom = controller.hiddenStateInsets.bottom
    val shownBottom = controller.shownStateInsets.bottom
    val startBottom = if (isImeShownAtStart) shownBottom else hiddenBottom
    val endBottom = if (isImeShownAtStart) hiddenBottom else shownBottom

    // We coerce the given inset within the limits of the hidden and shown insets
    val coercedBottom = inset.coerceIn(hiddenBottom, shownBottom)

    val consumedDy = controller.currentInsets.bottom - coercedBottom

    // Finally update the insets in the WindowInsetsAnimationController using
    // setInsetsAndAlpha().
    controller.setInsetsAndAlpha(
      // Here we update the animating insets. This is what controls where the IME is displayed.
      // It is also passed through to views via their WindowInsetsAnimation.Callback.
      Insets.of(0, 0, 0, coercedBottom),
      // This controls the alpha value. We don't want to alter the alpha so use 1f
      1f,
      // Finally we calculate the animation progress fraction. This value is passed through
      // to any WindowInsetsAnimation.Callbacks, but it is not used by the system.
      (coercedBottom - startBottom) / (endBottom - startBottom).toFloat(),
    )

    return consumedDy
  }

  /**
   * Return `true` if an inset animation is in progress.
   */
  fun isInsetAnimationInProgress(): Boolean {
    return insetsAnimationController != null
  }

  /**
   * Return `true` if an inset animation is currently finishing.
   */
  fun isInsetAnimationFinishing(): Boolean {
    return currentSpringAnimation != null
  }

  /**
   * Return `true` if a request to control an inset animation is in progress.
   */
  fun isInsetAnimationRequestPending(): Boolean {
    return pendingRequestCancellationSignal != null
  }

  /**
   * Cancel the current [WindowInsetsAnimationControllerCompat]. We immediately finish
   * the animation, reverting back to the state at the start of the gesture.
   */
  fun cancel() {
    insetsAnimationController?.finish(isImeShownAtStart)
    pendingRequestCancellationSignal?.cancel()

    // Cancel the current spring animation
    currentSpringAnimation?.cancel()

    reset()
  }

  /**
   * Finish the current [WindowInsetsAnimationControllerCompat] immediately.
   */
  fun finish() {
    val controller = insetsAnimationController

    if (controller == null) {
      // If we don't currently have a controller, cancel any pending request and return
      pendingRequestCancellationSignal?.cancel()
      return
    }

    val current = controller.currentInsets.bottom
    val shown = controller.shownStateInsets.bottom
    val hidden = controller.hiddenStateInsets.bottom

    when (current) {
      // The current inset matches either the shown/hidden inset, finish() immediately
      shown -> {
        InteractiveKeyboardProvider.shown = true
        controller.finish(true)
      }
      hidden -> {
        InteractiveKeyboardProvider.shown = false
        controller.finish(false)
      }
      else -> {
        // Otherwise, we'll look at the current position...
        if (controller.currentFraction >= SCROLL_THRESHOLD) {
          // If the IME is past the 'threshold' we snap to the toggled state
          InteractiveKeyboardProvider.shown = !isImeShownAtStart
          controller.finish(!isImeShownAtStart)
        } else {
          // ...otherwise, we snap back to the original visibility
          InteractiveKeyboardProvider.shown = isImeShownAtStart
          controller.finish(isImeShownAtStart)
        }
      }
    }
  }

  /**
   * Finish the current [WindowInsetsAnimationControllerCompat]. We finish the animation,
   * animating to the end state if necessary.
   *
   * @param velocityY the velocity of the touch gesture which caused this call to [animateToFinish].
   * Can be `null` if velocity is not available.
   */
  fun animateToFinish(velocityY: Float? = null) {
    val controller = insetsAnimationController

    if (controller == null) {
      // If we don't currently have a controller, cancel any pending request and return
      pendingRequestCancellationSignal?.cancel()
      return
    }

    InteractiveKeyboardProvider.isInteractive = false

    val current = controller.currentInsets.bottom
    val shown = controller.shownStateInsets.bottom
    val hidden = controller.hiddenStateInsets.bottom

    when {
      // If we have a velocity, we can use it's direction to determine
      // the visibility. Upwards == visible
      velocityY != null -> animateImeToVisibility(
        visible = velocityY < 0,
        velocityY = velocityY,
      )
      // The current inset matches either the shown/hidden inset, finish() immediately
      current == shown -> {
        InteractiveKeyboardProvider.shown = true
        controller.finish(true)
      }
      current == hidden -> {
        InteractiveKeyboardProvider.shown = false
        controller.finish(false)
      }
      else -> {
        // Otherwise, we'll look at the current position...
        if (controller.currentFraction >= SCROLL_THRESHOLD) {
          // If the IME is past the 'threshold' we animate it to the toggled state
          animateImeToVisibility(!isImeShownAtStart)
        } else {
          // ...otherwise, we animate it back to the original visibility
          animateImeToVisibility(isImeShownAtStart)
        }
      }
    }
  }

  fun getCurrentKeyboardHeight(): Int {
    val controller = insetsAnimationController
      ?: throw IllegalStateException(
        "Current WindowInsetsAnimationController is null." +
          "This should only be called if isAnimationInProgress() returns true",
      )

    return controller.currentInsets.bottom
  }

  private fun onRequestReady(controller: WindowInsetsAnimationControllerCompat) {
    // The request is ready, so clear out the pending cancellation signal
    pendingRequestCancellationSignal = null
    // Store the current WindowInsetsAnimationController
    insetsAnimationController = controller

    // Call any pending callback
    pendingRequestOnReady?.invoke(controller)
    pendingRequestOnReady = null
  }

  /**
   * Resets all of our internal state.
   */
  private fun reset() {
    // Clear all of our internal state
    insetsAnimationController = null
    pendingRequestCancellationSignal = null

    isImeShownAtStart = false

    currentSpringAnimation?.cancel()
    currentSpringAnimation = null

    pendingRequestOnReady = null
  }

  /**
   * Animate the IME to a given visibility.
   *
   * @param visible `true` to animate the IME to it's fully shown state, `false` to it's
   * fully hidden state.
   * @param velocityY the velocity of the touch gesture which caused this call. Can be `null`
   * if velocity is not available.
   */
  private fun animateImeToVisibility(
    visible: Boolean,
    velocityY: Float? = null,
  ) {
    @Suppress("detekt:UseCheckOrError")
    val controller = insetsAnimationController
      ?: throw IllegalStateException("Controller should not be null")

    currentSpringAnimation = springAnimationOf(
      setter = {
        insetTo(it.roundToInt())
      },
      getter = { controller.currentInsets.bottom.toFloat() },
      finalPosition = when {
        visible -> controller.shownStateInsets.bottom.toFloat()
        else -> controller.hiddenStateInsets.bottom.toFloat()
      },
    ).withSpringForceProperties {
      // Tweak the damping value, to remove any bounciness.
      dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
      // The stiffness value controls the strength of the spring animation, which
      // controls the speed. Medium (the default) is a good value, but feel free to
      // play around with this value.
      stiffness = SpringForce.STIFFNESS_MEDIUM
    }.apply {
      if (velocityY != null) {
        setStartVelocity(velocityY)
      }
      addEndListener { anim, _, _, _ ->
        if (anim == currentSpringAnimation) {
          currentSpringAnimation = null
        }
        // Once the animation has ended, finish the controller
        finish()
      }
    }.also { it.start() }
  }
}

/**
 * Scroll threshold for determining whether to animating to the end state, or to the start state.
 * Currently 15% of the total swipe distance distance
 */
private const val SCROLL_THRESHOLD = 0.15f

/**
 * A LinearInterpolator instance we can re-use across listeners.
 */
private val linearInterpolator = LinearInterpolator()
